Add microphone selection to Settings - #163
Merged
Merged
Conversation
A Microphone picker in Settings > General pins dictation to a specific input device, persisted as the device's CoreAudio UID (MicDeviceStore / BlurtMicDeviceUID). Un-pinned capture — the default — keeps the shipped AVAudioRecorder WAV path untouched; a pinned capture records through a fresh-per-session AudioQueue bound to the device via kAudioQueueProperty_CurrentDevice, behind a new CaptureRecorder seam inside MicCapture. The transport-keyed policies (liveness timeout, Bluetooth tail linger) and the warm-recorder identity check key off the pinned device's snapshot; a pinned device that isn't connected falls back to the system default per press (MicDeviceSelection.effective, pure and unit-tested) without unpinning. Device enumeration and UID translation live in AudioInputDevices (hardware-bound, coverage-excluded like AudioRoute). Engine tests stay off real CoreAudio: the new live suites ride the BLURT_LIVE_AUDIO_TESTS gate, and the pure decode/fallback/store rules are covered by MicDeviceStoreTests and the roster tests (now 12 keys).
- AudioQueueNewInput takes a capture-free closure literal forwarding to the static callback: a C function pointer cannot be formed from a static-method reference, only a top-level func or a literal closure. - The static callback drops the unused packet timing/description parameters (raw LPCM never needs them), which also satisfies swiftlint's five-parameter limit. - MicCapture.start(): break the warm-take/make-backend assignment the way swift-format asks (AddLines at the try).
Swift refuses the implicit inout-to-UnsafeRawPointer conversion for a variable whose type carries an object reference, so the kAudioQueueProperty_CurrentDevice value (the CFString reference itself) goes through withUnsafeMutablePointer — the same pattern AudioInputDevices already uses for the UID-translation qualifier, which this CI run compiled cleanly. Also swap the one key-path-inside-#expect in AudioInputDevicesTests for an explicit closure, per AGENTS.md's rethrows/key-path macro trap.
Owner-directed (Alex Kroman, 2026-08-25): replace both capture backends — the AVAudioRecorder/WAV path and the device-pinned AudioQueue — with one AVCaptureSession recorder (CaptureSessionRecorder) behind the existing CaptureRecorder seam. Still fresh per session: the session is built around the press-time resolution of the selection (pinned device via AVCaptureDevice(uniqueID:), else the default input, with the same pure missing-device fallback), its data output converts to 16 kHz mono 16-bit LPCM, and the delegate accumulates upload-ready S16LE in memory — no temp file, no decode pass. Liveness gate inputs re-derived on the new API with MicLiveness itself unchanged: the clock is the frames actually delivered (summed off each sample buffer), power is the connection's AVCaptureAudioChannel averagePowerLevel; fail-closed timeout, transport-keyed caps, and the Bluetooth tail linger all still key off the resolved device's CoreAudio snapshot. silenceFloorDB stays -115 dBFS — the dBFS math (0 = full scale, one int16 LSB ~ -90) is meter-independent — but was calibrated on the retired meter and must be re-verified on hardware. Warm-up now pre-builds the session without starting it — the device stays closed and no input indicator shows while idle — so route activation lands inside the connecting window at record()'s startRunning(). A built-but-idle session holds nothing open, so the 60 s warm expiry (which existed to un-pin AirPods from their degraded output profile) is deleted along with its generation tickets. Settled-decision prose updated consistently in AGENTS.md's table, the project-guardrails skill, and check-invariants.sh's advice string; the AVAudioEngine/installTap ban is unchanged and its anchors still pass --self-test. Tests updated to the new backend (warm suite identity probe replaces the generation counter; live suites stay env-gated).
The warm recorder existed to pre-pay a cost it never paid. Measured on hardware against CoreAudio's own kAudioDevicePropertyDeviceIsRunningSomewhere — the bit behind the input indicator — building an AVCaptureSession leaves the device closed and costs ~15 ms, while record()'s startRunning() opens it and costs 180 ms on the built-in mic, ~600 ms on a USB interface. The retired AVAudioRecorder measured the same way: prepareToRecord() is ~3 ms, leaves the device closed, and a cold record() after it cost 614 ms against 585 ms with no prepare at all. Neither API ever pre-paid the route activation, so the machinery built on the premise that one did was buying ~15 ms of a ~600 ms bring-up: a recorder re-warmed after every capture, validated against the resolved device identity and the pin it was built under, a 60 s expiry (since deleted with the AudioQueue backend) to un-pin AirPods from their degraded output profile, and a bringingUpCapture flag whose only job was to stop a re-warm racing a live press. warmUp() is now stateless: build one session for the current selection and drop it, which absorbs the ~75 ms a process pays the first time it touches AVFoundation's capture stack and holds nothing afterwards. The live suite pins the load-bearing half — building a recorder, and warming up, must leave the microphone closed, and record() must still open it — with a test-local HAL read rather than production code that only tests would call. Deleting warm removed the only consumer of the device identity a resolved input carried, which collapsed the rest: - CaptureRecorder is gone. It was a seam over two backends (the WAV recorder and the pinned AudioQueue); with one conformer left and no test double behind it, MicCapture uses CaptureSessionRecorder directly and the contract the protocol documented moved onto its members. MicCaptureProtocol is still the seam hosts and tests inject at. - AudioRoute.InputSnapshot is gone. A press needs the UID to pin and the transport for the liveness cap and tail linger; nothing asks which device it is anymore. - Enumeration, naming and presence moved to AVCaptureDevice, which is the API the recorder already opens the device with — uniqueID *is* the CoreAudio UID on macOS, confirmed round-tripping against built-in, USB and virtual devices — deleting a device-list read, an input-stream filter, a CFString property bridge and a UID→AudioDeviceID translation. "The pin resolves" and "the recorder can open it" are now one fact instead of two that could disagree. The transport read deliberately stays on CoreAudio: AVCaptureDevice exposes transportType with the same four-character codes, but that is unconfirmed on a Bluetooth device, and a transport that fails to read as Bluetooth silently costs the 2.5 s cap and the 220 ms linger — the missing-last-word bug both exist to fix. Verifying it with AirPods connected is the one thing left. - stopAndReadPCM no longer throws (the bytes are already in memory in the upload encoding — no read-back, decode or temp file left to fail at), and the liveness gate's clock probe is a frame count rather than a TimeInterval manufactured by dividing one by the sample rate. Prose that rested on the false premise is corrected rather than deleted: CueSoundPlayer's .connecting re-prime is still right, but because the *press* flips the AirPods profile, not because warm-up did; AppCoordinator's launch warm absorbs first-touch set-up, not route discovery; and MicLiveness no longer credits a re-warm with keeping the input open between dictations. A new settled-decisions row and guardrails bullet record the measurement, since "warm the mic to make presses feel faster" is exactly what gets re-added. swift test (628), the live audio suite (4, serialized — they share a device), the coverage gate (89.16%), both sanitizers, periphery and check-invariants --self-test all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the item the previous commit deliberately left open. The transport read was the one piece of the input path kept on CoreAudio, because AVCaptureDevice.transportType was confirmed to report the same four-character codes for built-in, USB, virtual and aggregate devices but *not* for Bluetooth — and Bluetooth is the only case the policies turn on. A transport that failed to read as `blue` would silently cost the 2.5 s liveness cap and the 220 ms tail linger, which is the missing-last-word bug both exist to fix. Measured with AirPods connected: AVCaptureDevice reports `blue`, so the parallel HAL read is gone — with it the UID→AudioDeviceID translation and AudioRoute's input half. AudioRoute is now just the default *output* device (AVCaptureDevice describes capture devices, so AudioRouteMonitor has no equivalent) plus the property addressing the two share. The same session surfaced two things the wired hardware could not, both in the live suite rather than the product: - `record()` returns before the device is open on Bluetooth. `startRunning()` takes ~80 ms on AirPods and the first frame lands ~410 ms later, where on a USB interface `startRunning()` blocks the whole ~600 ms and frames follow ~8 ms behind. The gate already handles it — that is what it is for, and the wait's clock starting after record() returns is what keeps the caps tight — but the new engagement test asserted the device was open the instant record() returned, which is true only on transports that block. It polls now. - The capture meter has a second not-ready value. Until AVCaptureAudioChannel's first update it reports -Float.greatestFiniteMagnitude (~-3.4e38), not the -160 a settled meter floors at, and that update can land *after* the first frames. Both consumers already treat it as silence: MicLiveness's `!(power > floor)` spelling counts an out-of-range reading as not-live, and linearLevel floors it. This also settles the "silenceFloorDB was calibrated on the retired meter, re-verify on hardware" caveat from the backend move: -115 dBFS sits correctly between the sentinel and a live AirPods mic. Both are now documented where the values are read, and the recorder test polls for frames and power instead of sleeping a fixed 500 ms — that sleep sat ~10 ms from the AirPods first-frame time and would have flaked on a cold link. check.sh green: 628 tests, 5 live tests on AirPods (serialized), coverage 89.16%, both sanitizers, periphery, app build, check-invariants --self-test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`record()` runs `AVCaptureSession.startRunning()`, which blocks for as long as the hardware takes to open — ~180 ms on the built-in mic, ~600 ms on a USB interface. Called inline from the actor it blocked `MicCapture` for that whole window. Measured: a teardown arriving during the open waited 578 ms on a 635 ms open. It also parked a cooperative-pool thread on a blocking call for the duration — the same pool the context capture that deliberately overlaps the bring-up and the transcriber's connection warm-up are running on, and starving that pool is how covering the retired backend in CI deadlocked the entire test run. `record()` is now async and hops to a dedicated serial sessionQueue on the recorder, so the open suspends the press instead of blocking anything. The same teardown now takes 24 µs. A DispatchQueue rather than a detached Task on purpose: the point is to park a Dispatch thread instead of a cooperative one. It is also deliberately not the delegate queue — blocking that for the length of an open would stall the very frames the liveness gate then waits for. Scope of the win, stated honestly: through DictationSession this is latent rather than user-visible. Its serial command queue already runs release and cancel only after the press turn completes, and cancel() on `.connecting` claims `.cancelled` synchronously without touching the mic, so Escape already answered the UI immediately. What was actually blocked was the teardown, and any host calling this public actor without serializing its own commands. stopRunning() stays inline: 19–41 ms against the open's ~600 isn't worth another suspension point on the release path the transcript waits behind, and it cannot race the queue — every caller runs on a recorder whose record() has already resumed. The new suspension moves the teardown snapshot: stopGeneration is read before the open rather than before the liveness wait, and checked at both ends, so a stop or cancel landing while the input is coming up wins and the recorder is torn down instead of installed. Without that, a press could install a live recorder into a session whose caller had already been told the stop was clean. MicCaptureBringUpTests pins the property. It calibrates against the real device and skips its own assertion when the input opens too fast for the window to be observable (AirPods open in ~100 ms), with BLURT_LIVE_AUDIO_INPUT_UID to point it at a slower input — which is how the USB case was covered on a machine whose default input is Bluetooth. check.sh green: 628 tests, 6 live tests, coverage 89.09%, thread and address sanitizers, periphery, app build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four review angles (reuse, simplification, efficiency, altitude) over
main...HEAD; findings deduped and applied. Two agents measured rather than
guessed, which changed two of my own numbers and caught one false comment.
Engine:
- start() had four hand-copied `recorder.stopAndDiscard()` exits and two
verbatim copies of the abandonment test. The teardown is now one
`defer { if !installed { … } }`, so a future suspension point inherits it and
the failure mode of forgetting one — a live session with no owner, hot input
indicator — can't come back. The two abandonment checks keep their distinct
positions and log lines; only the duplicated bodies went.
- resolveInput did two device lookups of the same device per press to ask one
question. AudioInputDevices.transportType(forUID:) now answers both: it
returns nil exactly when no connected device carries the pin, since a
transport read can't fail for a device that exists. isConnected is deleted,
and CaptureSessionRecorder resolves its pin through the same
AudioInputDevices.device(forUID:) — the two layers had drifted, the recorder's
spelling missing the isConnected guard, so a device that had just gone away
could be attached at one layer and called missing at the other.
- The session *build* joins the open off-actor. This was the /simplify finding I
first skipped, on the grounds that folding build into "make and start" would
destroy the live test's ability to assert that building leaves the mic closed.
Hopping it separately keeps both steps observable: make() and record() are
distinct async entry points on one static controlQueue (also now serializing
session control across recorders, per AVFoundation's guidance). The slimmed
bring-up test is what caught this — with its calibration probe gone, the
teardown measured 100 ms instead of 24 µs, all of it the inline build.
- warmUp() read the full press-time input resolution to use one field of it,
which spent two device lookups on a value the recorder re-resolves and could
log "pinned microphone not connected" at launch, where nothing is recording.
It reads MicDeviceSelection.pinnedUID now.
- activeTransportType stored a raw transport whose only use was deriving a
Duration, and was never cleared, so a stop with no capture computed a linger
from the previous one. It stores the Duration, cleared on detach — which also
removes stop()'s read-before-the-suspension caveat.
- logName was derivable state on the hardware class, spelling a string for a
caller that held the pin. Dropped; MicCapture logs the device instead.
- The upload geometry was six literals in a coverage-excluded file, and this
branch had deleted its only covered assertion along with decodePCM. Channels
and bit depth now come from SyncSTTLimits beside the byte math they must agree
with, and MicCaptureFormatTests pins the link — a comment I wrote last commit
claimed that test existed, and a reviewer caught that it didn't.
Tests:
- The BLURT_LIVE_AUDIO_TESTS gate was three copies of one condition and message;
it and the .liveAudio tag now live in LiveAudioSupport.swift.
- MicCaptureBringUpTests dropped ~40 lines of calibration machinery, an
undocumented env var, and a print-and-return that made it silently vacuous on
any fast input. It asserts an absolute budget (10 ms against a measured 24 µs)
which holds on every device, warms first the way the app does so it measures
the property rather than first-touch cost, and now also asserts the press
throws CancellationError — i.e. that the abandoned recorder was not installed.
- Three live suites open the same device while running in parallel, and one of
them asserts on kAudioDevicePropertyDeviceIsRunningSomewhere, which answers
for the device rather than for our client. It failed that way twice out of
two. LiveAudioDevice.acquire()/release() serializes them; three consecutive
runs green.
App:
- MicrophoneStepView moved to its own file (its stated reason for living inside
SoundStepView — keeping the generated project unchanged — doesn't hold, since
project.yml adds sources by directory; xcodegen regenerated).
- The picker binds MicDeviceSelection rather than the raw String, so
""-means-system is spelled once in the engine instead of again in a .tag("").
- Device enumeration moved off the main actor. It is a process's first touch of
the capture stack when Settings opens before any dictation — measured 150–500
ms cold — and it ran inline in onAppear while the window was laid out.
Prose made stale by this branch, including three files outside it: the
swift6-concurrency-reviewer subagent still told reviewers that AVAudioRecorder
was the deliberate backend, so it would have defended a backend this PR deleted
and flagged CaptureSessionRecorder as the violation; README.md's module map said
the same; MicCapture+Meter, DictationSession (twice) and a test still explained
themselves in terms of a disk read-back that no longer happens.
Skipped, with reasons: re-adding an injected recorder seam so the bring-up logic
is testable without hardware (a design decision that reverses 477efef, not a
cleanup — raising it separately); reading the transport off the recorder's
attached device (closes a narrow resolve→build race but moves the fallback
policy call into the recorder); warming the mic when permission is granted (new
behavior); a per-buffer allocation and a meter lookup both measured too cheap to
matter (0.035% of a core, 30 µs/s).
check.sh green: 630 tests, 6 live tests on real hardware, coverage 88.92%, both
sanitizers, periphery, app build, invariants --self-test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.claude/agents/swift6-concurrency-reviewer.md` told reviewers that cleanup "rides in the Sync STT `prompt`" — briefing a subagent that a field exists which AGENTS.md and the project-guardrails skill both forbid reintroducing, and which `ConversationContext.swift:30` says outright was replaced. Cleanup rides the `llm` block's `instruction` (`CleanupInstruction`); the steering field is `config.conversation_context`. Missed in fb67c86, which corrected the AVAudioRecorder bullet three lines above it in the same block. Caught by a reviewer reading the file afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sion `meteredPowerDB()` read `connection.audioChannels.first`, and `audioChannels` describes the *device's* channels, not the mono the data output converts to. Measured on this machine: a stereo interface, an aggregate and two virtual devices all report audioChannels=2 against outputChannels=1, and a channel carrying nothing reads -758 dBFS — far below silenceFloorDB (-115). So on any device whose microphone sits on input 2 — a 2-in interface with the mic in the second socket, or an aggregate whose first sub-device is silent — the gate metered a silent channel while the recorded mono had full signal: the liveness wait never confirmed, and the press failed CLOSED with "The microphone didn't start." on a mic that records perfectly. The retired AVAudioRecorder.averagePower(forChannel: 0) metered the recorded mono downmix, so channel 0 was the whole picture there; this was a behavior change that rode inside the backend swap unnoticed, through a green check.sh both times. Now a max over all channels. It slightly over-reads the true downmix (one loud channel of two averages quieter once mixed), which is the harmless direction for a floor probe and for meter bars. Also from the same review pass: - start()'s third suspension had no abandonment check. Moving the session build off-actor added it without one, which is precisely the drift the last commit predicted, so the guard is now one throwing `checkStillWanted(since:stage:)` called after each suspension — log line and throw included, so a fourth suspension point adds one line and cannot get the handling wrong. - The mic picker showed "Disconnected microphone" for the 150-500 ms of a cold device read, because the empty initial array read as "the pin is gone". A `devicesLoaded` flag separates "not read yet" from "read, and empty" (a flag rather than an optional array: `discouraged_optional_collection` is opted into repo-wide, and check.sh caught it). - A refused input or output logged nothing, leaving "no usable input device" or a fail-closed timeout with no explanation. - Two stale references of my own: a doc naming `sessionQueue` after the rename to `controlQueue`, and AGENTS.md still describing the bring-up test as calibrating and skipping itself after fb67c86 removed both. That is the second false doc claim I've introduced in this branch and the second caught by a reviewer. Accepted rather than fixed: the bring-up test's fixed 30 ms sleep. The safe window is roughly [1 ms, 80 ms] — the press must have started, and must not have completed — since the fastest open measured here is ~80 ms and the liveness gate then waits on a meter that lags the first frames. 30 ms sits mid-window; moving it to ~2 ms as suggested would trade the upper edge for the lower one. The assumption is now named in the test. Worth recording: both serious findings here live in files the coverage gate excludes, reachable only through code with no injectable seam, and check.sh was green through both. That is the concrete argument for the deferred recorder-seam decision — a stub returning a fixed meteredPowerDB() would have caught the first, and a stub whose make() suspends on a test-held continuation the second. check.sh green: 630 tests, 6 live tests on real hardware, coverage 88.92%, both sanitizers, swiftlint --strict, periphery, app build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old comment blamed `#expect(throws:)` not being able to capture an `async let`, which isn't the reason — the test cancels the press and then inspects how it ended, so the handle has to outlive the statement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Mez · Slack thread
Before: Blurt always records from the system default input, via a per-session
AVAudioRecorderwriting a temp WAV. After: a Microphone dropdown in Settings › General — "Same as system (current default's name)" plus each input device — and capture runs on a single per-sessionAVCaptureSessionrecorder that binds to the pinned device (or the default), converts to the dictation API's 16 kHz mono S16LE in its data output, and accumulates the upload blob in memory. The choice persists as the device UID, and dictation falls back to the system default whenever the pinned device isn't connected (without unpinning).What & why
Lets the user pin dictation to a specific microphone instead of always following the system default — e.g. keep AirPods for output while dictating into a USB mic.
How (single path, at Alex's direction): this PR originally shipped two backends — the untouched
AVAudioRecorderWAV path for "same as system" plus a device-pinned AudioQueue. Per Alex Kroman's direction it now has one:CaptureSessionRecorder, anAVCaptureSessionbuilt fresh per capture behindMicCapture'sCaptureRecorderseam (the fresh-recorder-per-session invariant is preserved; theAVAudioEngine/installTapban is untouched and itscheck-invariants.shanchors still pass--self-test; the settled-decisions prose in AGENTS.md / the guardrails skill / the script was updated consistently). Device selection isAVCaptureDevice(uniqueID:)— on macOS the uniqueID is the CoreAudio UIDMicDeviceStorepersists — with the pure, unit-tested missing-device fallback (MicDeviceSelection.effective) unchanged. The liveness gate's inputs are re-derived withMicLivenessitself unchanged: the clock is frames actually delivered (summed per sample buffer), power is the connection'sAVCaptureAudioChannel.averagePowerLevel; fail-closed timeout, transport-keyed caps, and the Bluetooth tail linger still key off the resolved device's CoreAudio snapshot. Warm-up pre-builds the session withoutstartRunning()— the device stays closed and no input indicator shows while idle — so route activation lands inside the press's connecting window (the liveness budget starts only afterrecord()returns). A built-but-idle session holds nothing open, so the 60 s warm expiry (which existed to un-pin AirPods from their degraded output profile) is deleted.Verify on real hardware (please read)
MicLiveness.silenceFloorDB(-115 dBFS) — the load-bearing one. Kept at -115: the dBFS math is meter-independent (full scale = 0, one int16 LSB ≈ -90.3, digital silence at/near the meter floor, -115 in the empty band between). But it was calibrated onAVAudioRecorder's meter; whereAVCaptureAudioChannel.averagePowerLevelactually bottoms out on zero-filled buffers — and what it reads before its first update — must be confirmed, or the gate could fail open. AirPods mid A2DP→HFP switch is the case that matters: the gate must keep holding on all-zero buffers until real samples flow.AudioInputDevicesTests(env-gated,BLURT_LIVE_AUDIO_TESTS=1) now asserts a live mic out-reads the floor.startRunning()cost lands at every press, inside the connecting pill. Measure it wired and on AirPods against the old feel (the liveness frame-arrival caps — 300 ms local / 1 s unknown / 2.5 s BT — start afterstartRunning()returns, so they shouldn't need retuning, but the perceived press→chime time may grow).AVCaptureDevice(uniqueID:)accepting the CoreAudio UID string for every device the picker lists (aggregates/virtual devices included).Notes for review:
SoundStepView.swift(+UITestIdentifiers, already in both targets), soproject.yml/project.pbxprojare untouched — written on Linux wherexcodegencan't run.CoreMedia(block-buffer copy in the delegate; ships under AVFoundation's umbrella). The AudioQueue backend and itsAudioToolboximport are gone. No SPM dependencies.decodePCMare gone with the old backend — the release path hands the accumulated S16LE straight to upload.How it was tested
On Linux — can't build/test Swift locally; CI on macOS is the authority.
scripts/check.sh --portablepasses (exit 0) includingcheck-invariants.shand its--self-test(the reworded settled-decision prose stays pinned in all three places). Suites:MicDeviceStoreTests(decode/round-trip/fallback),PersistedSettingsTests(roster 12 keys),MicCaptureFormatTests(meter math + error wording), the warm suite rewritten for the expiry-free lifecycle (identity probe, device/pin validation), live-gatedAudioInputDevicesTests(enumeration, UID round-trip, session-recorder capture + silence-floor assertion — engine tests never touch real CoreAudio ungated), and theSettingsUITestspicker case ("Same as system" default only).scripts/check.shpasses (or CI will, if I'm not on a Mac) — portable subset passes; full check runs on CI